Skip to content

feat: webMCP registers the editor's manifest tools on the host page - #47

Merged
bendersej merged 15 commits into
mainfrom
enable-webmcp
Sep 13, 2026
Merged

bendersej merged 15 commits into
mainfrom
enable-webmcp

Conversation

@bendersej

@bendersej bendersej commented Sep 2, 2026

Copy link
Copy Markdown
Member

Background

An in-browser agent (ChatGPT's browser, Chrome with WebMCP) discovers tools on the page it is looking at, not inside iframes. The SimplePDF editor registers its operations as WebMCP tools on its own page, so a site that embeds the editor exposes nothing to the agent. This PR lifts the editor's tools to the host page: webMCP registers, on the embedder's document.modelContext, the very records the editor publishes in its manifest (https://simplepdf.com/embed/json, operations[].tool) and forwards each call to the editor over the existing bridge. One tool set whether the editor is embedded or opened directly; the SDK restates nothing the manifest publishes.

It also carries the SDK regeneration against the current manifest (16 operations) and the lifecycle events derived from it.

Changes

  • createEmbed({ webMCP }) and <EmbedPDF webMCP={...} />: { enabled: true } registers every operation, { enabled: true, exclude: ['submit', ...] } withholds some by SDK method name, { enabled: false } and omitting the option are one state; a malformed value, an unknown key or a misspelled name throws EmbedConfigError at construction
  • Each tool is the manifest record verbatim: the simplepdf_embed_* name, description, snake_case input schema and behavior hints (readOnlyHint + untrustedContentHint on the readers, destructiveHint on the rest, openWorldHint where the editor fetches an agent-supplied URL); all 16 operations register, loadDocument included, like the editor's own registration
  • A tool call resolves with an MCP tool result carrying the editor's wire-shaped { success, data | error } Result (isError on failure); simplepdf_embed_get_annotated_page carries its PNG as an image content block, the badges map in the text block; the envelope is chosen per operation in an exhaustive switch, so a new operation has to declare text or image; a call whose signal is already aborted rejects and never reaches the editor
  • Registration waits for the editor to leave booting, re-probes for a model context on later transitions, and lazy-loads the WebMCP module only then; tool names are page-level (the first embed owns a name, a second one is warned); dispose() unregisters everything
  • getAnnotatedPage({ page }) joins IframeActions, useEmbed().actions and the tool subpaths; the generator honors the badges map shape (additionalProperties as Record<string, T> / z.record)
  • EDITOR_READY and DOCUMENT_LOADED come from the manifest events like PAGE_FOCUSED and SUBMISSION_SENT; internal-protocol.ts is gone and the EditorEvent union is drift-guarded for exact equality with the generated outbound events
  • The WebMCP docs state the data path as it is: every operation runs in the browser and nothing the agent reads is computed server-side; document storage follows the account's configuration exactly as without WebMCP (SimplePDF-managed, S3, Azure Blob Storage or SharePoint); loadDocument is named in the recommended exclude
  • Tests for the bridge path (registration set incl. loadDocument, exclude by method name, wire round trip in snake_case, image block and its fallback, an aborted call, readiness gate, dispose, name ownership, no-context and invalid-context diagnostics), the option validator (every malformed shape, an unknown key, an unknown name) and the React prop (registers, unregisters on unmount, the remount key's equivalence pairs); READMEs, three changesets (minor for both packages), API reports, bundle budgets

Notes

  • The WebMCP specification JSON-serializes whatever execute resolves with and defines no image channel; the image block pays off on runtimes that map results onto MCP's CallToolResult, and the plain-JSON path sees the same bytes either way (the PNG travels once).
  • Deliberately not done here: registering only the operations the account configuration allows (needs an EDITOR_READY payload change on the editor side); a client-side cap on in-flight tool calls (a same-page script has the same power today); the root README's data-path sentences (healthcare and Copilot sections), revisited in a follow-up.
  • Release lane: both packages ship as minor. The OutboundEventType widening (two members) is stated in its changeset.

Review outcomes

# What changed Before After Location
1 Tools are the manifest records, verbatim (required fix) No host-page tools; an SDK-side re-derivation (bare camelCase names, camelCase schemas, a hand-kept hints table) would drift from the editor's Generated WEBMCP_TOOLS carries the manifest tool per operation, registered as-is embed/src/webmcp.ts#L110-L170
2 Tool calls return the wire-shaped Result (required fix) Op results reach SDK callers camelCased A per-request resultShape keeps the WebMCP path on the snake_case shape its tool descriptions promise embed/src/bridge.ts#L482-L502
3 An unknown option key fails loud (required fix) No option { enabled: true, exlude: [...] } throws instead of reading as nothing withheld embed/src/mount.ts#L205-L250
4 The annotated page render travels once, as an image block No such operation in the SDK image block + text block with page, size and badges; one arm per operation so a new one must pick its envelope embed/src/webmcp.ts#L49-L95
5 Discriminated webMCP option, one decoder No option { enabled: false } | { enabled: true; exclude?: MethodName[] }, decoded once for the bridge, the module and the React key embed/src/webmcp-shared.ts#L23-L44
6 The lazy WebMCP chunk carries no operations table n/a Records carry their wireType; the opt-in download is 4.3 KB gzip against a 5 KB budget embed/scripts/lazy-chunks.mjs#L5
7 Generator honors a map output schema additionalProperties failed the build A schema-valued additionalProperties on an object without properties emits Record<string, T> / z.record; a map with required still fails loud embed/scripts/generate.mjs#L97-L108
8 Lifecycle events derive from the manifest Hand-authored INTERNAL_PROTOCOL for EDITOR_READY / DOCUMENT_LOADED Both come from OUTBOUND_EVENTS; the EditorEvent union is drift-guarded for exact equality embed/src/types.ts#L119-L126
9 Docs state the real data path (required fix) n/a Every operation runs in the browser and nothing the agent reads is computed server-side; storage follows the account's configuration (SimplePDF-managed, S3, Azure Blob Storage or SharePoint) exactly as without WebMCP, since a default embed uploads the document at load; loadDocument is named in the recommended exclude list embed/README.md#L74-L100
10 An already-aborted tool call never reaches the editor (required fix) n/a execute honors the specification's signal before posting; a call aborted after it was posted still runs in the editor, which the code states (the wire has no cancel frame) embed/src/webmcp.ts#L146-L152

…he model-context host list; retry after a failed chunk load
…t pin

Re-sync embed-api.json to the served /embed/json (16 operations): getAnnotatedPage joins
IframeActions, the tool subpaths and the WebMCP tools; the generator honors an object
schema whose additionalProperties is a value schema (a map: Record<string, T> / z.record),
the shape of the badges output. EDITOR_READY and DOCUMENT_LOADED come from the manifest
events like PAGE_FOCUSED and SUBMISSION_SENT: internal-protocol.ts is gone, the EditorEvent
union is drift-guarded for exact equality with the generated outbound events, and the root
exports EditorReadyPayload / DocumentLoadedPayload. The lazy WebMCP chunk budget follows the
extra operation. Changesets for both packages.
…nt included

The host-page WebMCP tools are the editor's own records from /embed/json (operations[].tool):
the simplepdf_embed_* name, description, snake_case input schema and behavior hints, generated
into webmcp-tools.ts and registered as-is, so a page gets one tool set whether the editor is
embedded or opened directly. The hand-kept TOOL_ANNOTATIONS table and the camelCase
tool-input-schemas.ts are gone. All 16 operations register (loadDocument included, like the
editor); the /tools, /ai-sdk and /tanstack-ai subpaths are unchanged.

The option is webMCP: { enabled: false } | { enabled: true; exclude?: MethodName[] } on
createEmbed and <EmbedPDF>; { enabled: false } and omitted are one state; exclude takes SDK
method names, validated at construction against the generated method-names.ts list.

Tool calls resolve with the editor's wire-shaped Result (what the record's description
promises); the annotated page render travels once, as an MCP image content block, with the
badges map in the text block. Lazy chunk budget follows the verbatim records.
…e needs no OPERATIONS table

The opt-in chunk dropped the shared operations table (a second copy of every description
and the error-code arrays, 43% of its download) now that each generated record names the
operation it dispatches to; its budget follows (4.5 KB). A map node with a required list
fails the generator instead of losing the list; the option validator builds its error only
when it throws; the protocol header scopes is_agentic_tool to the tool registries; the
README recommends withholding loadDocument alongside submit; the React remount key's two
equivalence pairs are pinned.
…budgets

The root grew 197 B gzip for the 16th operation, the manifest lifecycle events and the
result-shape plumbing, leaving 68 B under its cap against the file's 0.5-1.5 KB convention;
the caps follow the measured sizes here, where the growth is explained.
…from the hint set; scope the in-tab claim

An unknown key on the webMCP option throws at createEmbed, so { enabled: true, exlude: [...] }
can no longer read as nothing withheld. The generated annotation type lists the same hint names
the generator validates. The README and changeset state what stays in the tab: editing, until
a submit call sends the document through the tenant's submission flow.
…ustive switch

A new operation now has to state at this site whether its result is text or a picture; the
single-operation predicate let it take the text envelope unnoticed.
@bendersej bendersej changed the title feat: enableWebMCP registers the editor operations as WebMCP tools on the host page feat: webMCP registers the editor's manifest tools on the host page Sep 13, 2026
…tool call never posts; docs and stubs follow the 16-op contract

The README and changeset no longer say the document reaches no SimplePDF storage until
submit (a default embed uploads it to the configured storage at load); they state what is
true: every operation runs in the browser, nothing the agent reads is computed server-side,
and storage follows the account's configuration (SimplePDF-managed, S3, Azure Blob Storage
or SharePoint) exactly as without WebMCP. A WebMCP call whose signal is already aborted
rejects before reaching the editor. The changelog names the loadDocument contract change the
manifest sync brought in; the shared actions stub and the tool router test cover
getAnnotatedPage; the option-key guard is a real drift guard; the contract header names both
root imports; the build-with-simplepdf skill documents webMCP.
@bendersej
bendersej merged commit 06a5946 into main Sep 13, 2026
3 checks passed
@bendersej
bendersej deleted the enable-webmcp branch September 13, 2026 14:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant